Go 1.25 带来了全新的容器感知 GOMAXPROCS 默认设置。这个改进让容器工作负载的默认行为变得更加合理,避免了影响尾延迟的限流问题,并提升了 Go 的开箱即用生产就绪性。
在这篇文章中,我们将深入探讨 Go 如何调度 goroutine,这种调度如何与容器级别的 CPU 控制交互,以及 Go 如何通过感知容器 CPU 控制来获得更好的性能。
GOMAXPROCS
Go 的一大优势是通过 goroutine 提供了内置且易用的并发能力。从语义角度来看,goroutine 与操作系统线程非常相似,让我们能够编写简单的阻塞代码。另一方面,goroutine 比操作系统线程更轻量,创建和销毁的成本要低得多。
虽然 Go 的实现可以将每个 goroutine 映射到一个专用的操作系统线程,但 Go 通过运行时调度器保持 goroutine 的轻量性,让线程变得可互换。任何 Go 管理的线程都可以运行任何 goroutine,因此创建新 goroutine 不需要创建新线程,唤醒 goroutine 也不一定需要唤醒另一个线程。
但是,有了调度器就会带来调度问题。比如,我们到底应该使用多少个线程来运行 goroutine?如果有 1000 个可运行的 goroutine,我们应该在 1000 个不同的线程上调度它们吗?
这就是 GOMAXPROCS 发挥作用的地方。
从语义上讲,GOMAXPROCS 告诉 Go 运行时应该使用的"可用并行度"。
更具体地说,GOMAXPROCS 是同时用于运行 goroutine 的线程的最大数量。所以,如果 GOMAXPROCS=8 且有 1000 个可运行的 goroutine,Go 会使用 8 个线程一次运行 8 个 goroutine。
通常,goroutine 运行很短的时间然后阻塞,这时 Go 会切换到在同一个线程上运行另一个 goroutine。Go 还会抢占那些不会自行阻塞的 goroutine,确保所有 goroutine 都有机会运行。
从 Go 1.5 到 Go 1.24,GOMAXPROCS 默认为机器上 CPU 核心的总数。
注意,在这篇文章中,"核心"更准确地说是指"逻辑 CPU"。例如,一台有 4 个物理 CPU 且支持超线程的机器有 8 个逻辑 CPU。
这通常是"可用并行度"的一个很好的默认值,因为它自然匹配硬件的可用并行度。也就是说,如果有 8 个核心且 Go 同时运行超过 8 个线程,操作系统就必须将这些线程复用到 8 个核心上,就像 Go 将 goroutine 复用到线程上一样。这种额外的调度层并不总是问题,但它是不必要的开销。
容器编排
Go 的另一个核心优势是通过容器部署应用程序的便利性。在容器编排平台中部署应用程序时,管理 Go 使用的核心数量尤其重要。
像 Kubernetes 这样的容器编排平台会获取一组机器资源,并根据请求的资源在可用资源内调度容器。
要在集群资源内尽可能多地打包容器,平台需要能够预测每个调度容器的资源使用情况。
我们希望 Go 遵守容器编排平台设置的资源利用约束。
让我们以 Kubernetes 为例,探讨 GOMAXPROCS 设置在这种环境下的影响。
像 Kubernetes 这样的平台提供了限制容器消耗资源的机制。Kubernetes 有 CPU 资源限制的概念,它向底层操作系统发出信号,说明特定容器或容器集合将被分配多少核心资源。
设置 CPU 限制会转换为创建 Linux 控制组 CPU 带宽限制。
在 Go 1.25 之前,Go 不知道编排平台设置的 CPU 限制。相反,它会将 GOMAXPROCS 设置为部署机器上的核心数量。
如果设置了 CPU 限制,应用程序可能会尝试使用远超限制允许的 CPU。为了防止应用程序超过其限制,Linux 内核会限流应用程序。
限流是限制容器的一种粗暴机制,它会在剩余的限流期间完全暂停应用程序执行。限流期间通常是 100 毫秒,因此与较低 GOMAXPROCS 设置的软调度复用效果相比,限流可能会造成显著的尾延迟影响。
即使应用程序从来没有太多并行性,Go 运行时执行的任务(如垃圾收集)仍然可能导致触发限流的 CPU 峰值。
新的默认设置
我们希望 Go 在可能的情况下提供高效可靠的默认值,因此在 Go 1.25 中,我们让 GOMAXPROCS 默认考虑其容器环境。
如果 Go 进程在有 CPU 限制的容器内运行,当 CPU 限制小于核心数量时,GOMAXPROCS 将默认为 CPU 限制值。
容器编排系统可能会动态调整容器 CPU 限制,因此 Go 1.25 也会定期检查 CPU 限制,如果发生变化会自动调整 GOMAXPROCS。
这两个默认值只在 GOMAXPROCS 未指定的情况下适用。设置 GOMAXPROCS 环境变量或调用 runtime.GOMAXPROCS 的行为与以前相同。
runtime.GOMAXPROCS 文档涵盖了新行为的详细信息。
略有不同的模型
GOMAXPROCS 和容器 CPU 限制都对进程可以使用的最大 CPU 量设置了限制,但它们的模型略有不同。
GOMAXPROCS 是并行度限制。 如果 GOMAXPROCS=8,Go 永远不会同时运行超过 8 个 goroutine。
相比之下,CPU 限制是吞吐量限制。 也就是说,它们限制在某个时间段内使用的总 CPU 时间。默认时间段是 100 毫秒。因此,"8 CPU 限制"实际上是在每 100 毫秒的时间段内限制 800 毫秒的 CPU 时间。
这个限制可以通过连续运行 8 个线程整个 100 毫秒来填满,这相当于 GOMAXPROCS=8。
另一方面,这个限制也可以通过运行 16 个线程,每个线程运行 50 毫秒,其他 50 毫秒处于空闲或阻塞状态来填满。
换句话说,CPU 限制并不限制容器可以运行的 CPU 总数。它只限制总 CPU 时间。
大多数应用程序在 100 毫秒时间段内的 CPU 使用相当一致,因此新的 GOMAXPROCS 默认值与 CPU 限制匹配得相当好,肯定比总核心数更好!
然而,值得注意的是,特别尖峰的工作负载可能会因为这个变化而看到延迟增加,因为 GOMAXPROCS 会阻止超出 CPU 限制平均值的额外线程的短期峰值。
此外,由于 CPU 限制是吞吐量限制,它们可以有小数部分(例如,2.5 CPU)。另一方面,GOMAXPROCS 必须是正整数。因此,Go 必须将限制四舍五入为有效的 GOMAXPROCS 值。Go 总是向上舍入以启用完整 CPU 限制的使用。
CPU 请求
Go 的新 GOMAXPROCS 默认值基于容器的 CPU 限制,但容器编排系统还提供了"CPU 请求"控制。
CPU 限制指定容器可以使用的最大 CPU,而 CPU 请求指定始终保证可供容器使用的最小 CPU。
创建具有 CPU 请求但没有 CPU 限制的容器是很常见的,因为这允许容器利用超出 CPU 请求的机器 CPU 资源,这些资源由于其他容器缺乏负载而空闲。
不幸的是,这意味着 Go 无法基于 CPU 请求设置 GOMAXPROCS,这会阻止利用额外的空闲资源。
当机器繁忙时,具有 CPU 请求的容器在超出其请求时仍然会受到约束。超出请求的基于权重的约束比 CPU 限制的硬性基于周期的限流更"软",但高 GOMAXPROCS 的 CPU 峰值仍然可能对应用程序行为产生不利影响。
我应该设置 CPU 限制吗?
我们已经了解了 GOMAXPROCS 过高导致的问题,以及设置容器 CPU 限制可以让 Go 自动设置适当的 GOMAXPROCS,因此一个显而易见的下一步是想知道是否所有容器都应该设置 CPU 限制。
虽然这可能是自动获得合理 GOMAXPROCS 默认值的好建议,但在决定是否设置 CPU 限制时,还有许多其他因素需要考虑,比如通过避免限制来优先利用空闲资源 vs 通过设置限制来优先可预测的延迟。
GOMAXPROCS 与有效 CPU 限制不匹配的最坏行为发生在 GOMAXPROCS 显著高于有效 CPU 限制时。例如,在 128 核机器上运行的接收 2 CPU 的小容器。
这些是最值得考虑设置显式 CPU 限制或者显式设置 GOMAXPROCS 的情况。
结论
Go 1.25 通过基于容器 CPU 限制设置 GOMAXPROCS,为许多容器工作负载提供了更合理的默认行为。这样做避免了可能影响尾延迟的限流,提高了效率,并且通常试图确保 Go 开箱即用就是生产就绪的。
你可以简单地通过在 go.mod 中将 Go 版本设置为 1.25.0 或更高来获得新的默认设置。
感谢社区中所有为实现这一目标做出贡献的人员,特别是来自 Uber 的 go.uber.org/automaxprocs 维护者的反馈,他们长期以来一直为用户提供类似的行为。
上一篇文章:Go 1.25 发布
博客索引
